cfscript: support a new expression as a function-listener target (#98) - #130
Merged
Merged
Conversation
`threadName = new Query():function( result, error ) { … };`. That
completes the eleven forms in Lucee's Function Listeners recipe; the
other ten landed in #96 and #97.
The +591 this issue was parked on had expired. It was measured when
new_expression could complete on the bare keyword `new`; requiring its
arguments — shipped separately — removed that, and the same widening
then measured +14. A cost taken before a related change is not evidence
about after it.
What actually blocked it was precedence, not size. With the target
widened, `new Foo()` before a contested `:` is either a listener target
or a ternary consequence, and only an open `?` tells them apart. The rule
shipped in #87 sits at prec.right('call', …), and 'call' binds tighter
than 'ternary', so the listener reading won inside `c ? new Foo() : obj`
and that ternary stopped parsing — the failure every earlier attempt hit,
including the +260 variant that restricted the listener side to a
function literal.
So the `new` target is a second arm below 'ternary', with the conflict
[$.expression, $.function_listener_expression] that tree-sitter generate
asks for. Where a `?` is open the ternary wins; where none is, nothing
competes for the colon. The call-target arm keeps 'call' and is
untouched.
+53 parse states (5315 -> 5368). Corpus 640 -> 638 error nodes across
121 -> 120 files, FunctionListener.cfc going to zero and nothing
regressing. `npm run treediff` reports zero changed trees in both
grammars.
The conflict was benchmarked, as a declared conflict must be: cfscript
+1.0% against untouched controls spanning 4.2 points (cfml +2.5%,
cfquery -1.7%), so inside the noise floor. That is what the prefix
predicts — the conflict is live on a new_expression followed by `:` and
nowhere else.
Both ternary controls, `c ? new Foo() : obj` and `isNull(o) ? new() : o`,
are pinned by a corpus test alongside the construct, because they are
what every earlier attempt broke. Probe
cfscript/function_listener_new.cfc flips to pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117EvdX3EgLgDevVNrWZ5hr
Master has since taken #122 (#115), #131, #129 (#80), #128 (#116) and #124 (#117). cfscript/grammar.js and the corpus tests auto-merged; conflicts were CHANGELOG.md, docs/TODO.md and the generated cfscript/src/parser.c, which is regenerated rather than hand-merged. Re-measured against the new baseline: STATE_COUNT 5436 -> 5489 (+53, unchanged), corpus 644 -> 642 error nodes across 120 -> 119 files (FunctionListener.cfc to zero, nothing else moved), tree-shape diff zero changed files in both grammars, npm test / probe / lint / fuzz green. The declared conflict was re-benchmarked: cfscript +0.5% against untouched controls at +0.9% and -2.9%, so the subject moved less than two grammars that did not change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117EvdX3EgLgDevVNrWZ5hr
ghedwards
pushed a commit
that referenced
this pull request
Sep 18, 2026
Master has since taken #130 (#98). The only conflict was the in-flight section of docs/TODO.md, both sides added; every generated parser stays byte-identical to master, since this change is entirely in common/scanner.h. Re-measured against the new baseline: corpus 642 -> 638 error nodes across 119 -> 117 files (the same two files to zero, nothing else moved), tree-shape diff zero changed files in both grammars, npm test / probe / lint / fuzz green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117EvdX3EgLgDevVNrWZ5hr
ghedwards
pushed a commit
that referenced
this pull request
Sep 18, 2026
Master has since taken #122 (#115), #131, #129 (#80), #128 (#116), #124 (#117) and #130 (#98). #128 landed a second external token in the same three places this branch edits, so the real conflicts were the externals list, the scanner's symbol enum and the scan() dispatch tail: - externals / enum: _empty_arrow_body keeps its position, _savecontent_kw appended after it, the two kept in the same order in both files. - dispatch tail: EMPTY_ARROW_BODY first, SAVECONTENT_KW last. Both are valid after `=>`, and savecontent consumes the word before it can tell whether it matched, so the zero-width one has to get its answer first. cfscript/src/grammar.json and parser.c are regenerated, not hand-merged. STATE_COUNT 5489 -> 5517 (+28, unchanged). Also fixes a defect that was in the branch before this merge: the lookahead past mark_end used skip(), which resets a token's start, so _savecontent_kw came out zero-width on the `{` with the word covered by no node. advance() is the correct lookahead once mark_end has fixed the end. Nothing in the gate could see it - a corpus test compares S-expressions and those carry no ranges - so the rule is recorded in the parse-gap skill's references/scanner.md. Re-measured: corpus 642 -> 641 error nodes across 119 -> 118 files (_LDEV3623.cfc to zero, nothing else moved), tree-shape diff zero changed files in both grammars, npm test / probe / lint / fuzz green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117EvdX3EgLgDevVNrWZ5hr
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #98 — the residual half, after #102 shipped the
arguments-required change.threadName = new Query():function( result, error ) { … };parses. That completes the eleven forms in Lucee's Function Listeners recipe; the other ten landed in #96 and #97.The number this issue was parked on had expired
#98 records the widening at +591 states and parks the issue on that. That figure was measured when
new_expressioncould complete on the bare keywordnew— and #102 removed exactly that by requiring itsarguments. Re-measured on today's base, the same widening costs +14.The transferable part: a cost taken before a related change is not evidence about after it. Nothing about this feature got cheaper; the measurement simply stopped describing the grammar it was taken from.
What actually blocked it was precedence, not size
With the target widened,
new Foo()before a contested:is either a listener target or a ternary consequence, and only an open?tells them apart — which is what GLR carries. The rule shipped in #87 sits atprec.right('call', …), and'call'binds tighter than'ternary', so the listener reading won insidec ? new Foo() : objand that ternary stopped parsing. Every earlier attempt failed this way, including the +260 variant that restricted the listener side to a function literal.The fix is a second arm below
'ternary', plus the conflict[$.expression, $.function_listener_expression]thattree-sitter generateasks for. Where a?is open the ternary wins; where none is, nothing competes for the colon. The call-target arm keeps'call'and is untouched.new Query():function( r, e ) { … }ERRORfunction_listener_expressionwith anew_expressiontargetnew org.lucee.cfml.Query():callbackERRORc ? new Foo() : objisNull(o) ? new() : oQuery():function( … )(call target)x = new.foo,var new = 1,obj.new()a[ f() : g() ]slice,case f():Verification
Re-run in full after merging
master, which has since taken #122 (#115), #131, #129 (#80), #128 (#116) and #124 (#117).cfscript/grammar.jsand the corpus tests auto-merged;cfscript/src/parser.cwas regenerated, never hand-merged.npm testnpm run probecfscript/function_listener_new.cfcflips topassnpm run lint/npm run fuzzFunctionListener.cfcto zero, nothing regressednpm run treediffvsmasterSTATE_COUNTnpm run benchThe scan diff against
masteris two lines and nothing else, both inlucee_Lucee/test/general/FunctionListener.cfc.The benchmark is the gate
CLAUDE.mdrequires for a declared conflict, and the controls are the point: the subject moved less than two grammars that did not change at all, so the cost is inside the noise floor. That matches what the prefix predicts — this conflict is live on anew_expressionfollowed by:and nowhere else, unlike theidentifier (shapes that cost 2.8×.Also in the diff
function listener on a new target (#98)pinning both ternary controls beside the construct, because they are what every earlier attempt broke.LIMITATIONS.mdanddocs/FAILING-PATTERNS.mdrewritten from rejection to record, keeping the expired-measurement lesson and the still-valid reasonsnewis not reserved (395.new(call sites across 160 corpus files; Lucee's reserved-word list isnull,true,false).CHANGELOG.mdunder## [Unreleased], and the in-flight entry indocs/TODO.md.🤖 Generated with Claude Code
https://claude.ai/code/session_0117EvdX3EgLgDevVNrWZ5hr